/* @flow */

import type {Config} from './config';

import appendImportantToEachValue from './append-important-to-each-value';
import cssRuleSetToString from './css-rule-set-to-string';
import getState from './get-state';
import getStateKey from './get-state-key';
import cleanStateKey from './clean-state-key';
import getRadiumStyleState from './get-radium-style-state';
import hash from './hash';
import {isNestedStyle, mergeStyles} from './merge-styles';
import Plugins from './plugins/';

import ExecutionEnvironment from 'exenv';
import React from 'react';
import StyleKeeper from './style-keeper';

const DEFAULT_CONFIG = {
  plugins: [
    Plugins.mergeStyleArray,
    Plugins.checkProps,
    Plugins.resolveMediaQueries,
    Plugins.resolveInteractionStyles,
    Plugins.keyframes,
    Plugins.visited,
    Plugins.removeNestedStyles,
    Plugins.prefix,
    Plugins.checkProps
  ]
};

// Gross
let globalState = {};

// Only for use by tests
let __isTestModeEnabled = false;

export type EnhancerApi = {
  state: Object,
  setState: Function,
  _radiumMediaQueryListenersByQuery?: {
    [query: string]: {remove: () => void}
  },
  _radiumMouseUpListener?: {remove: () => void},
  _radiumIsMounted: boolean,
  _lastRadiumState: Object,
  _extraRadiumStateKeys: any,
  _radiumStyleKeeper?: StyleKeeper
};

type ResolvedStyles = {
  extraStateKeyMap: {[key: string]: boolean},
  element: any
};

// Declare early for recursive helpers.
let resolveStyles = ((null: any): (
  component: any, // ReactComponent, flow+eslint complaining
  renderedElement: any,
  config: Config,
  existingKeyMap: {[key: string]: boolean},
  shouldCheckBeforeResolve: boolean,
  extraStateKeyMap?: {[key: string]: boolean}
) => ResolvedStyles);

const _shouldResolveStyles = function(component) {
  return component.type && !component.type._isRadiumEnhanced;
};

const _resolveChildren = function({
  children,
  component,
  config,
  existingKeyMap,
  extraStateKeyMap
}) {
  if (!children) {
    return children;
  }

  const childrenType = typeof children;

  if (childrenType === 'string' || childrenType === 'number') {
    // Don't do anything with a single primitive child
    return children;
  }

  if (childrenType === 'function') {
    // Wrap the function, resolving styles on the result
    return function() {
      const result = children.apply(this, arguments);

      if (React.isValidElement(result)) {
        const key = getStateKey(result);
        delete extraStateKeyMap[key];
        const {element} = resolveStyles(
          component,
          result,
          config,
          existingKeyMap,
          true,
          extraStateKeyMap
        );
        return element;
      }

      return result;
    };
  }

  if (React.Children.count(children) === 1 && children.type) {
    // If a React Element is an only child, don't wrap it in an array for
    // React.Children.map() for React.Children.only() compatibility.
    const onlyChild = React.Children.only(children);
    const key = getStateKey(onlyChild);
    delete extraStateKeyMap[key];
    const {element} = resolveStyles(
      component,
      onlyChild,
      config,
      existingKeyMap,
      true,
      extraStateKeyMap
    );
    return element;
  }

  return React.Children.map(children, function(child) {
    if (React.isValidElement(child)) {
      const key = getStateKey(child);
      delete extraStateKeyMap[key];
      const {element} = resolveStyles(
        component,
        child,
        config,
        existingKeyMap,
        true,
        extraStateKeyMap
      );
      return element;
    }

    return child;
  });
};

// Recurse over props, just like children
const _resolveProps = function({
  component,
  config,
  existingKeyMap,
  props,
  extraStateKeyMap
}) {
  let newProps = props;

  Object.keys(props).forEach(prop => {
    // We already recurse over children above
    if (prop === 'children') {
      return;
    }

    const propValue = props[prop];
    if (React.isValidElement(propValue)) {
      const key = getStateKey(propValue);
      delete extraStateKeyMap[key];
      newProps = {...newProps};
      const {element} = resolveStyles(
        component,
        propValue,
        config,
        existingKeyMap,
        true,
        extraStateKeyMap
      );
      newProps[prop] = element;
    }
  });

  return newProps;
};

const _buildGetKey = function({
  componentName,
  existingKeyMap,
  renderedElement
}) {
  // We need a unique key to correlate state changes due to user interaction
  // with the rendered element, so we know to apply the proper interactive
  // styles.
  const originalKey = getStateKey(renderedElement);
  const key = cleanStateKey(originalKey);

  let alreadyGotKey = false;
  const getKey = function() {
    if (alreadyGotKey) {
      return key;
    }

    alreadyGotKey = true;

    if (existingKeyMap[key]) {
      let elementName;
      if (typeof renderedElement.type === 'string') {
        elementName = renderedElement.type;
      } else if (renderedElement.type.constructor) {
        elementName =
          renderedElement.type.constructor.displayName ||
          renderedElement.type.constructor.name;
      }

      throw new Error(
        'Radium requires each element with interactive styles to have a unique ' +
          'key, set using either the ref or key prop. ' +
          (originalKey
            ? 'Key "' + originalKey + '" is a duplicate.'
            : 'Multiple elements have no key specified.') +
          ' ' +
          'Component: "' +
          componentName +
          '". ' +
          (elementName ? 'Element: "' + elementName + '".' : '')
      );
    }

    existingKeyMap[key] = true;

    return key;
  };

  return getKey;
};

const _setStyleState = function(component, key, stateKey, value) {
  if (!component._radiumIsMounted) {
    return;
  }

  const existing = getRadiumStyleState(component);
  const state = {_radiumStyleState: {...existing}};

  state._radiumStyleState[key] = {...state._radiumStyleState[key]};
  state._radiumStyleState[key][stateKey] = value;

  component._lastRadiumState = state._radiumStyleState;
  component.setState(state);
};

const _runPlugins = function({
  component,
  config,
  existingKeyMap,
  props,
  renderedElement
}) {
  // Don't run plugins if renderedElement is not a simple ReactDOMElement or has
  // no style.
  if (
    !React.isValidElement(renderedElement) ||
    typeof renderedElement.type !== 'string' ||
    !props.style
  ) {
    return props;
  }

  let newProps = props;

  const plugins = config.plugins || DEFAULT_CONFIG.plugins;

  const componentName =
    component.constructor.displayName || component.constructor.name;
  const getKey = _buildGetKey({
    renderedElement,
    existingKeyMap,
    componentName
  });
  const getComponentField = key => component[key];
  const getGlobalState = key => globalState[key];
  const componentGetState = (stateKey, elementKey) =>
    getState(component.state, elementKey || getKey(), stateKey);
  const setState = (stateKey, value, elementKey) =>
    _setStyleState(component, elementKey || getKey(), stateKey, value);

  const addCSS = css => {
    const styleKeeper = component._radiumStyleKeeper;
    if (!styleKeeper) {
      if (__isTestModeEnabled) {
        return {remove() {}};
      }

      throw new Error(
        'To use plugins requiring `addCSS` (e.g. keyframes, media queries), ' +
          'please wrap your application in the StyleRoot component. Component ' +
          'name: `' +
          componentName +
          '`.'
      );
    }

    return styleKeeper.addCSS(css);
  };

  let newStyle = props.style;

  plugins.forEach(plugin => {
    const result =
      plugin({
        ExecutionEnvironment,
        addCSS,
        appendImportantToEachValue,
        componentName,
        config,
        cssRuleSetToString,
        getComponentField,
        getGlobalState,
        getState: componentGetState,
        hash,
        mergeStyles,
        props: newProps,
        setState,
        isNestedStyle,
        style: newStyle
      }) || {};

    newStyle = result.style || newStyle;

    newProps =
      result.props && Object.keys(result.props).length
        ? {...newProps, ...result.props}
        : newProps;

    const newComponentFields = result.componentFields || {};
    Object.keys(newComponentFields).forEach(fieldName => {
      component[fieldName] = newComponentFields[fieldName];
    });

    const newGlobalState = result.globalState || {};
    Object.keys(newGlobalState).forEach(key => {
      globalState[key] = newGlobalState[key];
    });
  });

  if (newStyle !== props.style) {
    newProps = {...newProps, style: newStyle};
  }

  return newProps;
};

// Wrapper around React.cloneElement. To avoid processing the same element
// twice, whenever we clone an element add a special prop to make sure we don't
// process this element again.
const _cloneElement = function(renderedElement, newProps, newChildren) {
  // Only add flag if this is a normal DOM element
  if (typeof renderedElement.type === 'string') {
    newProps = {...newProps, 'data-radium': true};
  }

  return React.cloneElement(renderedElement, newProps, newChildren);
};

//
// The nucleus of Radium. resolveStyles is called on the rendered elements
// before they are returned in render. It iterates over the elements and
// children, rewriting props to add event handlers required to capture user
// interactions (e.g. mouse over). It also replaces the style prop because it
// adds in the various interaction styles (e.g. :hover).
//
/* eslint-disable max-params */
resolveStyles = function(
  component: EnhancerApi,
  renderedElement: any, // ReactElement
  config: Config = DEFAULT_CONFIG,
  existingKeyMap: {[key: string]: boolean} = {},
  shouldCheckBeforeResolve: boolean = false,
  extraStateKeyMap?: {[key: string]: boolean}
): ResolvedStyles {
  // The extraStateKeyMap is for determining which keys should be erased from
  // the state (i.e. which child components are unmounted and should no longer
  // have a style state).
  if (!extraStateKeyMap) {
    const state = getRadiumStyleState(component);
    extraStateKeyMap = Object.keys(state).reduce((acc, key) => {
      // 'main' is the auto-generated key when there is only one element with
      // interactive styles and if a custom key is not assigned. Because of
      // this, it is impossible to know which child is 'main', so we won't
      // count this key when generating our extraStateKeyMap.
      if (key !== 'main') {
        acc[key] = true;
      }
      return acc;
    }, {});
  }

  if (Array.isArray(renderedElement) && !renderedElement.props) {
    const elements = renderedElement.map(element => {
      // element is in-use, so remove from the extraStateKeyMap
      if (extraStateKeyMap) {
        const key = getStateKey(element);
        delete extraStateKeyMap[key];
      }

      // this element is an array of elements,
      // so return an array of elements with resolved styles
      return resolveStyles(
        component,
        element,
        config,
        existingKeyMap,
        shouldCheckBeforeResolve,
        extraStateKeyMap
      ).element;
    });
    return {
      extraStateKeyMap,
      element: elements
    };
  }

  // ReactElement
  if (
    !renderedElement ||
    // Bail if we've already processed this element. This ensures that only the
    // owner of an element processes that element, since the owner's render
    // function will be called first (which will always be the case, since you
    // can't know what else to render until you render the parent component).
    (renderedElement.props && renderedElement.props['data-radium']) ||
    // Bail if this element is a radium enhanced element, because if it is,
    // then it will take care of resolving its own styles.
    (shouldCheckBeforeResolve && !_shouldResolveStyles(renderedElement))
  ) {
    return {extraStateKeyMap, element: renderedElement};
  }
  const children = renderedElement.props.children;

  const newChildren = _resolveChildren({
    children,
    component,
    config,
    existingKeyMap,
    extraStateKeyMap
  });

  let newProps = _resolveProps({
    component,
    config,
    existingKeyMap,
    extraStateKeyMap,
    props: renderedElement.props
  });

  newProps = _runPlugins({
    component,
    config,
    existingKeyMap,
    props: newProps,
    renderedElement
  });

  // If nothing changed, don't bother cloning the element. Might be a bit
  // wasteful, as we add the sentinel to stop double-processing when we clone.
  // Assume benign double-processing is better than unneeded cloning.
  if (newChildren === children && newProps === renderedElement.props) {
    return {extraStateKeyMap, element: renderedElement};
  }

  const element = _cloneElement(
    renderedElement,
    newProps !== renderedElement.props ? newProps : {},
    newChildren
  );

  return {extraStateKeyMap, element};
};
/* eslint-enable max-params */

// Only for use by tests
if (process.env.NODE_ENV !== 'production') {
  resolveStyles.__clearStateForTests = function() {
    globalState = {};
  };
  resolveStyles.__setTestMode = function(isEnabled) {
    __isTestModeEnabled = isEnabled;
  };
}

export default resolveStyles;
